ゲーム設計メモ on Godot
アクションゲームの概要
ゲーム全体の流れと必要な要素
タイトル
デベロッパーロゴ、ライセンスロゴ
タイトル画面
はじめから
ステージ前デモへ
続きから
ロード画面→ステージへ
設定
スクリーンサイズ
キーコンフィグ
オーディオ
ゲーム終了
ステージ前デモ(スキップが有効)
テキストダイアログの表示
ボタン/時間で次に進むか、全体をスキップ
1枚絵の表示
スクロールやズーム、フラッシュやシェイクなどの効果
ステージ(ポーズ画面が有効)
ステージ開始演出
ステージ
プレイヤー
出現(ゲーム開始、チェックポイントからの復帰)
移動やジャンプ
攻撃:刀を振ったり弾の発射
ダメージをうける。死亡する→ゲームオーバー演出
アイテムを拾う
スイッチを入れる。ギミックを発動する。
イベントを起こす(チェックポイント、イベントエリアへの突入)
敵
出現
移動
攻撃:体当たりや弾の発射
ダメージを受ける。倒される
スイッチを入れる場合がある。
地形
トラップ、動く床
アイテム
取得する
使用する(回復、ギミックの発動など)
ギミック
スイッチ。連動して開く扉。
チェックポイント
セーブ
ポーズ
タイトルに戻る
ボス
ボスの出現
必要に応じてデモシーン
ボスの体力ゲージ表示
ボスの攻撃
複数パターン、それらをランダムに選ぶか、状況で選ぶか、ループするか。
ゲームオーバー演出
ゲームクリア演出
エンディング(スキップが有効)
エンディングデモ
スタッフロール
タイトル画面へ
シーンの構造
◆System
最初に実行される。
ゲーム全体の管理。
タイトル画面、ステージからゲーム終了シグナルを受け付ける。
タイトル画面から設定内容を受取り、ゲーム進行状況とは別のファイルに保存する。
タイトル画面からゲーム開始シグナルを受け付ける。デモ(オープニング)シーンのロードを行う。
ステージからチェックポイント更新シグナルを受け付ける。ここに保持しておかないとステージ丸ごと再読み込みしての再開ができない。
ステージからゲーム保存シグナルを受け付ける。現在のチェックポイント内容をファイルに保存する。
ステージからステージクリアシグナルを受け付ける。次のデモやステージをロードする。
タイトル画面、ステージからゲーム再開シグナルを受け付ける。ゲームオーバーからのリトライ、あるいはセーブデータを読み込んで途中から再開。
ステージからポーズシグナルを受け付ける。もしステージ内でポーズ処理を行うと、ステージ丸ごとポーズしたときに復帰ができなくなる。
デモシーンからデモ終了シグナルを受け付ける。次のステージを読み込む、あるいはタイトルに戻る。
◆TitleScreen
タイトル画面
はじめる/つづきから/設定/終了のUI入力。
はじめる/つづきから の画面から次のステージやデモシーンを決定してSystemにシグナルを送る。
設定内容をSystemに送る。
◆デモシーン
ステージ前デモシーンやエンディング。終了時にシグナルを送る。
◆ステージ
実際のゲーム。
ポーズボタンでポーズすることができる
プレイヤーが死亡した時、チェックポイント更新時、ステージクリア時にSystemにシグナルを送る。
キャラクターになるもの
基本的に、プレイヤーキャラクターが活躍する舞台に地形(地面や壁)があって、ぶつかる可能性があるならCharacterBody2Dをベースに作る。ぶつかる地面も壁も存在しない宇宙空間や海上などを舞台にするならNode2Dか、Area2Dを使って接触判定領域そのものをキャラクターとして動かしても良い。RigidBody2Dはキャラクターに向かず、ステージに転がしておく障害物用途で使う。StaticBody2Dは基本的に壁。
接触判定を検知するノード。
接触判定自体は他のノードでも不可能ではないが、できることが限られるので判定といえばもっぱらArea2Dと考えたほうが良い。
基本はCharacterBody2Dなど動くノードの「子」として接触判定を担う。
CollisionShape2Dが必要
area_entered / body_enteredシグナルで衝突判定
用途:ステージの壁や床など。キャラクターの行く手を阻んだり、飛んできた物体を跳ね返すもの。
基本的にステージ上の障害物用途で使う。このノードをキャラクターに設定して動かしたりはしないほうが良い。その用途では作られていない。
Mass: 重さ
Gravity Scale: 重力の強さ
RigidBodyを仕込んだキャラクターが倒れないようにする
Deactivation>Lock Rotationをオン
用途:プレイヤーキャラクター、敵キャラクター、動く床など。
地形(地面や壁)にそって移動させたい、あるいは何かに乗ったり乗せたりぶつかったりする物体の移動を制御したい。
移動の基本的な機能が揃っていて簡単に実装できる。子として攻撃判定、食らい判定のノードをArea2Dで実装する。
motion_modeをGroundedに設定する必要がある。
カメラ
基本的にはCamera2Dをプレイヤーキャラクターの子に追加しておけば、カメラ(視点)は勝手にプレイヤーを追従する。
既定ではキャラクターは常に画面の真ん中に固定されるので、ジャンプの回数が多いゲームだと画面が上下に揺さぶられて見辛い。「あそび」を持たせたければインスペクタのDrag>Horizonal EnabledとVertical Enabledをオンにする。あそびの幅はその下の ???Marginで設定する。
あそびの設定は真ん中固定が0、1が画面の端で、たとえば0.8に設定すると中心から8割外れた位置まで行ってようやくスクロールする。移動スピードにもよるが、アクションゲームだと0.8は全然前が見えないのでやりすぎ。デフォルトの0.2がちょうどいいのかも。
右スティックで道の先を見せたいということならdrag_horizontal/vertical_offsetをスティック操作で変化させれば良い。このとき、そのままだどカクカクして見づらいが、Position Smoothingをオンにして加速度を指定してやるとかなり見やすくなる。
問題は、Position Smoothingをオンにしておくと、ワープなどで大きく移動させた時、カメラの移動過程も見えてしまうこと。しかしPosition Smoothingを移動する直前にオフにするだけでは不十分…対策考え中
ジャンプ処理
CharacterBody2Dにスクリプトを当てた時点でジャンプ処理は自動的に入っている
code:gd
# Handle jump.
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = JUMP_VELOCITY
ジャンプボタンを押す長さで高さを変える
あらかじめ最大加速度で飛ばしておき、ジャンプボタンを離したことを検知したタイミングで上方向加速度を制限する。ただし、いきなり加速度をゼロにしてしまうとカクンとなり違和感がすごいので何分の1とかにするとかがいいと思う
code:gd
if not is_on_floor():
# 空中でジャンプボタンを離した
if Input.is_action_just_released("jump"):
if velocity.y < 0:
velocity.y /= 2.0
# 重力を加算
velocity.y += gravity * delta
ジャンプボタン押しっぱなしで加速し続ける処理にするとジェット噴射っぽい挙動になる
2段ジャンプ処理
フラグをひとつ持って、ジャンプ中にジャンプボタンを押したときにフラグを立てて上方向に再加速、地上に降り立ったときにフラグを初期化すれば良い。3段ジャンプ以上ならフラグ(bool)ではくカウンタ(int)になる
code:gf
# ダブルジャンプ
var double_jumped:bool = false
...
func _physics_process(delta):
...
if is_on_floor():
# 地上にいる
double_jumped = false
else:
# 空中にいる
# 空中でジャンプボタンを押した
if Input.is_action_just_pressed("jump") and not double_jumped:
velocity.y = JUMP_VELOCITY
double_jumped = true
動く床
まず動く床のベースはCharacterBody2Dになる。RigidBodyでは動きを制御できないほか、キャラクターに押されて動いてしまうし、キャラクターが乗ってると重さがかかってしまって動かなくなることがある。プレイヤーが乗ることができ、かつ動作を自前で制御できるCharacterBody2Dがふさわしい。
動く床のコリジョンにはOne way Collisionを設定しておくと親切(プレイヤーを押しつぶしたい意図がなければ)。
ステージ上にAnimationPlayerを置く方法
ステージにAnimationPlayerを直接置いて、新規アニメを作成し、動く床の位置をトラックとして登録して、ゲーム開始時に再生する方法。ステージ上のすべての床の位置をシンクロさせることができるので確かに便利…
スクリプトで動かす方法
移動スピードで管理せず「到達までにかかる時間」で管理し、時間から現在位置を割り出して位置(position)を直接指定する。こうすれば同じ時間にきまった位置にいることになり、複数の動く床の位置がずれて困るなんてことにならない。(動く床から動く床に乗り移らせたいだろうし)
移動先にマーカーが必要。
code:gd
class_name MovingPlatform
extends CharacterBody2D
# 移動先地点マーカー(開始時に位置を記憶するだけなので子にしても問題ない)
@export var to_marker: Marker2D
# 移動時間
@export var way_time: float = 3.0
# 移動方向
enum STATE {
forward,
backward,
}
var state = STATE.forward
# 開始位置
var from_location:Vector2
# 終了位置
var to_location:Vector2
# 経過時間
var past_time:float = 0.0
func _ready() -> void:
# 現在位置を記憶する
from_location = global_position
to_location = to_marker.global_position
func _physics_process(delta:float) -> void:
match state:
STATE.forward:
# 順行
past_time += delta
if past_time >= way_time:
past_time = way_time
state = STATE.backward
STATE.backward:
# 逆行
past_time -= delta
if past_time <= 0:
past_time = 0
state = STATE.forward
# 加速度や衝突など計算せず、位置を直接指定する
position = from_location.lerp(to_location, past_time/way_time)
衝突処理
衝突する側、される側、どちらが処理するかの切り分け
衝突する対象は以下のようなものがある
A. プレイヤーと敵が相互に影響を与える
プレイヤーと敵が接触(たいあたり)してダメージを受ける
敵の弾や刀とプレイヤーが接触してダメージを受ける
プレイヤーの弾や刀と敵が接触してダメージを与える
B. ステージ設置物がプレイヤーに影響を与える
ステージ上のトラップに接触してダメージを受ける(即死する)
ステージ上のアイテムを拾う
C. プレイヤーがステージ設置物を通じてステージやシステムに影響を与える
チェックポイントに接触する
イベント開始エリアに接触する
スイッチなどのステージギミックを起動する
A. プレイヤーと敵が相互に影響を与える(攻撃して/されてダメージを与える/受ける場合)
攻めAreaは攻撃力を保持し、あとは出現させたり消したりを管理するのみ。
受けAreaが攻撃力を受け取ってダメージを算出しライフを減らす。
まず受け側のコリジョンを一時的に無効にする(無敵時間)。攻め側を無効にしてしまうと複数の敵にヒットしない。ヒットした攻撃オブジェクトは記憶しておく。
攻撃側のオブジェクトの種類を調べ、有効であれば攻撃力を受け取り、ダメージ処理。
ライフがゼロになったら死亡処理へ。
ダメージアニメを再生し、終わったらコリジョンを戻す。この時同じ攻撃オブジェクトに連続して多段ヒットしないようにする。(あるいは、ヒットする回数を数える)
B. ステージ設置物がプレイヤーに影響を与える
設置部側は接触を感知してアイテムを消したりチェックポイントの絵を変えたりする必要があるのでその処理のみ行う。
プレイヤー側も接触シグナルに従ってアイテムを増やしたりダメージを受けたり即死したりする処理を行う。
アイテムを取る
アイテム側がアイテム情報を持ち、取る側がアイテム情報を受け取ってスコアを計算したりインベントリに追加する。
アイテム側は、自分自身を消去する必要がある。取得アニメーションがあればその前に再生する。
アイテム情報の例
アイテムID
重要度(取得したときのエフェクトにかかわる)
おおまかな効果(スコア加算、体力回復、インベントリに格納など)
効果値(回復量など)
code:gd
enum ITEM_PRIORITY {
low = 0,
middle = 1,
high = 2,
}
enum ITEM_TYPE {
score = 0,
life = 1,
magic = 2,
gold = 3,
inventory = 4,
}
@export var item_id: int = 0
@export var item_priority: ITEM_PRIORITY = ITEM_PRIORITY.low
@export var item_type: ITEM_TYPE = ITEM_TYPE.score
@export var item_value: float = 0
スクリプトによるコリジョンレイヤーのインタラクティブな変更
例えばプレイヤーがダメージを受けたとする。一時的に無敵状態になる。しかし衝突判定を丸ごと消してしまうと、無敵時間にアイテムが取得できず、、即死トラップにもひっかからず、イベントが発生するエリアやチェックポイント、ゴールゲート等をすり抜けてしまう。のでこれらの判定を有効にしたまま敵と敵の攻撃のみ無効にしたい。
簡易版ライフバーのつくりかた
プレイヤー側でライフを変更(初期化したりダメージを受けたり回復したり)するタイミングで発火するイベントを作る。
code:gd
# なぜライフがfloatになってるのか…
# フレーム毎ゆっくり回復する処理を入れたかったが、小数点以下の数値はintだと切り捨てられてしまうから
@export var life: float = 100.0
@export var max_life: float = 100.0
# 体力を表示するイベント:HUDで受け取る
# 表示単位はドットなのでintで渡す
signal disp_life(life: int)
func add_life(add_value: float):
life += add_value
change_life(life)
func change_life(value: float):
life = value
if life < 0.0:
# 本来はここで死亡処理
life = 0.0
if life > max_life
life = max_life
disp_life.emit(int(life)) # intに変換
HUDにスクリプトをアタッチし、HUDで上記イベントを受け取る
ColorRectのsize.xに反映する
ポーズ画面
シーンを一時停止したければ<シーン>.get_tree().paused = trueで止まるが…
まず、ポーズするシーンとポーズされないシーンを切り分ける必要がある。もしすべてのシーンがポーズできてしまうと、ポーズ時にゲーム全体が止まってしまうので、ポーズ解除キーすら受付不能になってしまう。ポーズされるシーンとポーズされないシーンは、NodeのProcess Modeで設定できる。Alwaysにすればポーズを無視して動き続ける
Inherit : 親、祖父母などの状態に応じて処理します。非 Inherit 状態を持つ最初の親。
Pausable: ゲームが一時停止されていない場合にのみ、ノード (および継承モードのその子) を処理します。
WhenPaused :ゲームが一時停止されている場合にのみ、ノード (および継承モードのその子) を処理します。
Always : 一時停止しているかどうかに関係なく、このノードは処理を行います。
Disabled : ノード (および継承モードのその子) はまったく処理されません。
システムシーン(ポーズ画面の受付、タイトル画面やステージなどの読み込みを受け持つ。このprosess modeはAlwaysに設定する)
ステージシーン(ここを止める。process modeはPausableにする)
code:gd
# _unhandled_key_inputではどこかでブロックされる可能性が高い
func _input(event: InputEvent) -> void:
match game_scene:
GAMESCENE.stage:
event.is_action_pressed("pause"):
var s = $Stage as Stage
if s.get_tree().paused :
paused = false
get_tree().paused = paused
$PauseScreen.visible = false
else:
paused = true
s.get_tree().paused = paused
$PauseScreen.visible = true
マップ管理
ステージシーン上に手で敵を配置する作業に限界を感じてきた。敵、チェックポイント、ステージギミック、ゴール地点の配置は地形(タイルマップ)のデザインに密接に関連しているため、可能なかぎり同じタイミングで編集してデータも一元管理したい。
TileMapはレイヤー(Layers)管理ができるため、地形レイヤーと設置物レイヤーを分け、ステージ開始時にレイヤー内の情報を調べて設置物をスポーンさせる処理を行うことにした。
code:gd
const Enemy = preload("res://scripts/Enemy.gd")
const EnemyScene = preload("res://scenes/enemy/enemy.tscn")
...
func _ready() -> void:
for node in get_children(false):
# ステージ内のマップを取得する
# ふたつあった場合は考えない。あえて名前でアクセスしないことで汎用性をもたせる
if node is TileMap:
stage_map = node
# 敵・イベント配置レイヤーを参照
# ここれはレイヤーNo.1が敵等の配置情報でソースNo.1が仮のタイルソースとする
var eventPositions:ArrayVector2i = stage_map.get_used_cells_by_id(1,1) for eventPosition in eventPositions:
var coords:Vector2i = stage_map.get_cell_atlas_coords(1,eventPosition)
# ここでは8x8の仮のタイルソースを用意し、(0,0)を空白に、(1,0)~(8,1)を敵とした。
# 今回は1種類のみだが将来的には15種類の敵を扱えるようにする
# 同様に2行目はチェックポイント、3行目はステージギミック…と続く
if coords.y == 0 or coords.y == 1:
# シーンからインスタンス化
var spawned_enemy:Enemy = EnemyScene.instantiate()
# 敵の「弾発射」シグナルを接続する
spawned_enemy.enemy_shoot.connect(_on_enemy_shoot)
# 位置の微調整
spawned_enemy.position = eventPosition * 32 + Vector2i(16,8)
# スポーン
add_child(spawned_enemy)
elif coords.y == 2:
# チェックポイントスポーンさせる
...
# 最後に敵・イベント配置レイヤーを非表示にする
stage_map.set_layer_enabled ( 1, false )